iT邦幫忙

2026 iThome 鐵人賽

DAY 2
0
AI Engineering

從單一agent 到多agent 集群的開發流水帳以及應用系列 第 2

Day2 把自家 AI CLI 拆開,對照五家商業級專案

  • 分享至 

  • xImage
  •  

前言:先知道自己手上有什麼

我在做一個叫 spectyn 的東西。目標是從一顆 agent 走到多 agent 集群,最後把整套接進自己的生活裡天天用。

寫了幾個月,累積下來大概是十幾萬行 Rust。但要往下一步走之前,我得先誠實回答一個問題:我手上這個東西,現在到底長什麼樣子?

我沒有從自己寫的設計文件開始查。文件會腐爛,而且腐爛的方式很難察覺——你不會收到通知,只會在某天發現照著文件做行不通。所以我從程式碼開始,一個模組一個模組地讀,把行數、呼叫關係、還有誰真的在執行路徑上,全部重新量一次。

量完之後,我把結果拿去對照五個我每天在用、或跟我做同一件事的專案:

  • opencodecodexagyclaude——這四家就裝在我這台 Mac 上,所以我直接解剖它們的命令面,不引用文件
  • hermes-agent(Nous Research)——沒裝,但我的 wrapper 腳本裡有它的介接紀錄,加上官方文件

這篇是那次盤點的完整結果。結論先說:我缺的不是零件。真正的病是同一件事被我實作了兩次,而且常常只有一套是活的。


一、把自家 CLI 拆開

規模

模組 行數 角色
bin/ 20,490 CLI 入口,61 個 subcommand
tools/(33 檔) 17,989 工具實作
skillbank/(46 檔) 13,061 技能萃取/召回
serve.rs 11,195 HTTP daemon,96 條路由
tui.rs 10,630 終端機介面
life_node/(25 檔) 9,259 生活擷取
providers/(25 檔) 8,727 供應商層
agent.rs 4,174 agent 迴圈
governed_run/ 3,752 治理層
crew/ 3,183 多廠牌併審
cli_session/ 1,304 驅動別家 AI CLI

主幹

bin/spectyn.rs(61 個 subcommand)
  └─ AgentRuntime::run*()        5 個入口
      └─ run_inner
          ├─ for round in 0..max_rounds      回合迴圈
          ├─ providers/ → 解析供應商 → 串流
          ├─ 'stream: loop                   token / tool-call 解析
          ├─ ToolGateDecision                工具閘(權限 profile)
          ├─ tools/(33 個模組)
          │    └─ process_sandbox            Landlock / Seatbelt
          ├─ hallucination                   反幻覺掃描
          └─ AgentEvent → callbacks

五個入口,一個迴圈

AgentRuntime 對外開了五個入口,差別只在「誰在聽」:

入口 用途
run 最單純,跑完給結果
run_tracked 多帶成本追蹤
run_tracked_with_session 多帶對話持久化
run_with_callbacks 多帶事件回呼(TUI、WebSocket 用這個)
run_with_callbacks_gated 再多一層工具閘

五個最後都收束到同一個 run_inner。這是好設計——一條迴圈,五種觀察方式

但往下一層就不是了。run_inner 裡選供應商的地方,樹裡有三份幾乎相同的實作。這不是我推論的,是原始碼註解自己寫的:

「這是第三條程式路徑⋯⋯之前是寫死的,忽略了 agent 的供應商清單和執行期覆寫,所以在某個介面下切換模型根本沒生效。」

一個 bug 被修了三次,因為同一件事被寫了三遍。而註解裡還誠實記下了那次真實事故:某個節點把 A 家的模型名送到 B 家的端點,換來一個 401。

把上面這些畫成一張圖,順便標出哪些是活的、哪些不是:

https://ithelp.ithome.com.tw/upload/images/20260826/20092056HxwGdrTh2h.png

實線是活的,虛線是寫好了但不在執行路徑上,粗框是重構時不能弄丟的。右下角那三個虛線框,是這篇後半要講的主題。

這套架構最好的部分:事件語彙只有七種

Token · Thinking · ToolStart · ToolDone · Done · Notice · ConsistencyWarning

就這七個。TUI、exec --json、HTTP daemon 的 WebSocket、手機端——全部消費同一組事件

小而穩的介面是重構時最珍貴的東西,因為它是唯一你可以放心不動的部分。後面所有要改的,都改在它兩側。

兩層外掛式的 agent 驅動

L1  governed_run/    硬煞車 + 簽章飛行記錄器 + 手機升級
     └─ L0  cli_session/    互動式 AI-CLI session substrate
              └─ claude / codex / agy / opencode

這裡有個被平台逼出來的分工,寫在原始碼註解裡:agy 的介接需要 pseudo-terminal,而那個函式庫在 Android 上編不過。所以行動端永遠不在本機驅動 AI CLI,只當遠端 supervisor

架構有時候不是設計出來的,是被限制刻出來的。


二、對照五家商業級

先講一個我自己的工具鏈早就做出的分類

我有一支 wrapper 腳本負責統一呼叫各家 AI CLI。翻它的原始碼時發現,它把這些東西分成三類:

dev trio       : codex · agy · opencode(+ claude)
gateway agents : openclaw · hermes · spectyn      ← 我自己在這一類
local LLM      : ollama

寫這支腳本的時候我沒特別想過這件事,但它是對的。 前四家是寫程式的 CLI;hermes 和我做的東西是另一種物種——完整的個人 agent runtime。

所以下面的對照要分成兩組看:跟 dev trio 比介面設計,跟 hermes 比定位。

dev trio + claude:四家都裝在這台機器上

版本與體積:

版本 大小
opencode 1.17.12 124M
codex 0.149.1 210M
agy 1.1.19 170M
claude 2.1.243 345M

opencode —— client/server 是骨架

serve            無介面 server
attach <url>     連上執行中的 server
web              起 server + 開網頁介面
acp              Agent Client Protocol server(編輯器 ↔ agent)
plugin <module>  外掛
session / export / import
stats            token 用量與成本

https://ithelp.ithome.com.tw/upload/images/20260826/20092056VsBh3vQ6xk.png

opencode web 特別值得看:daemon 自己就是 web server。這正好是我一直在想的那個問題的現成答案——如果介面只在 app 和網頁裡出現,那 UI 應該由 daemon 送出,而不是放在別的網域再回撥本機。

codex —— session 是一等公民

app-server        共用的本機 daemon
agents            瀏覽「共用 daemon 上」的所有 agent session
remote-control    以遠端控制管理該 daemon
mcp / mcp-server  MCP 雙向
sandbox           沙盒是使用者可直接呼叫的命令
apply             把 agent 產出的 diff 當可保存產物,事後 git apply
resume  fork  queue  archive  unarchive  delete  migrate-rollouts

https://ithelp.ithome.com.tw/upload/images/20260826/20092056pz0UVA2Ztx.png

七個子命令都在管 session。對話不是暫存,是可以分岔、封存、排隊、遷移的物件。

agy —— 以旗標為介面,結構化 I/O 完整

--mode accept-edits|plan       執行模式
--effort low|medium|high       推理力道
--sandbox                      終端限制
--input-format  stream-json    從 stdin 讀 NDJSON,每行跑一輪
--output-format text|json|stream-json
--json-schema <schema|path>    強制結構化輸出

https://ithelp.ithome.com.tw/upload/images/20260826/20092056YsJhSOEC1f.png

子命令只有四個,其餘全是旗標。設計取向很清楚:它假設你是用程式呼叫它,不是用手打它。

claude —— 擴充點最多

session:  --fork-session --session-id --resume --continue
權限:      --permission-mode --allowedTools --safe-mode
成本:      --max-budget-usd --fallback-model --autocompact
MCP:      mcp --mcp-config --strict-mcp-config
外掛:      plugin --plugin-dir --plugin-url
遠端:      --remote-control --teleport --bg --cloud --ide
agent:    --agents '<inline JSON>' --system-prompt
其他:      import(從別家 AI coding agent 匯入設定)

https://ithelp.ithome.com.tw/upload/images/20260826/20092056zNBs3imkfc.png

--max-budget-usd--fallback-model 這兩個特別值得注意——成本上限和降級模型是每次呼叫都能指定的,不是埋在設定檔裡。

四家共通的九個模式

  1. 無介面 daemon + 多 client
  2. session 是可 resume / fork / archive 的物件
  3. 結構化 I/O 是一等介面(四家都有 --output-format json|stream-json
  4. 外掛系統
  5. MCP 雙向(同時當 server 和 client)
  6. 沙盒是看得見的介面(子命令或旗標,不是設定檔深處)
  7. 成本與預算可見
  8. 模型降級是每次呼叫可指定的
  9. doctor

值得抄的三件,以及為什麼

看完四家,真正想抄的不是功能,是三個取向

① 一個核心,多個 client(opencode、codex 都是)

opencodeserve / attach / webcodexapp-server / agents / remote-control。兩家的骨架一樣:無介面的核心跑在背景,介面全部是它的客戶。

對照我這邊:終端機介面 10,630 行是行程內實作,HTTP daemon 11,195 行是另一套。同一組能力兩套殼,兩邊各自維護。如果終端機介面改成 daemon 的一個 client、共用那七種事件,兩邊分歧的問題會直接消失。

這也回答了另一個我一直在想的問題:如果最終產品只在桌面 app、手機 app 和網頁裡出現,那網頁該怎麼連到本機?答案不是「把網頁放在某個網域再回撥本機」——那會撞上瀏覽器的三道限制(混合內容、私有網路預檢、跨來源政策)。答案是 opencode web 那個做法:daemon 自己就是 web server,同源,三個問題都不存在。

② session 是物件,不是暫存(codex)

七個子命令管 session:resume、fork、queue、archive、unarchive、delete、migrate。其中 fork 最值得抄——從某個歷史點分岔出一條新對話。這件事在多 agent 的場景下不是便利功能,是必需品:同一個脈絡讓三家 AI 各跑一次,然後比較,這需要分岔。

我這邊 session 只有即時檢視。

③ 危險的東西要看得見(codex、agy、claude 都是)

沙盒、權限模式、預算上限、降級模型——四家都把這些放在使用者每天看得到的介面上,不是設定檔深處。

claude--max-budget-usd 特別能說明問題:花錢的上限應該是每次呼叫都能指定的,因為每次任務的風險不一樣。埋進設定檔的話,人只會設定一次然後忘記。

我這邊三樣都有實作,三樣都沒有入口。

hermes-agent —— 唯一跟我同一類的專案

前四家的定位是寫程式的工具。hermes-agent(Nous Research,MIT 授權)不是——它的定位是個人 agent runtime,主打內建的學習迴路。

那跟我在做的是同一件事,所以這是五個裡面唯一能做「同類比較」的對象。

https://ithelp.ithome.com.tw/upload/images/20260826/20092056eXPk25ijnV.png

功能重疊的部分:

hermes 我的對應
學習迴路:從經驗生成技能,並在使用中自我改進 技能萃取/召回,13,061 行
agent 策展的記憶 + 定期 nudge 自有記憶 + 召回
FTS5 跨 session 搜尋 + LLM 摘要 同樣是 FTS5
cron 排程,可投遞到任何平台 教練排程器
spawn 隔離 subagent 平行工作 多廠牌併審
MCP 整合 有(缺管理命令)
hermes doctor

兩邊在全文檢索上都選了 FTS5。這大概是同一個約束(本機、無外部服務、要能跨 session 搜尋)導向同一個答案。

它有、我沒有的部分:

項目 hermes
執行後端 七種:local、Docker、SSH、Singularity、Modal、Daytona、Vercel Sandbox。後三種閒置時休眠 自己的機器組成的 mesh
訂閱聚合 Nous Portal:模型 + 網頁搜尋 + 圖像生成 + TTS + 雲端瀏覽器,一份訂閱
平台覆蓋 單一 gateway process 同時服務 Telegram、Discord、Slack、WhatsApp、Signal、Email 手機網頁介面 + Telegram
遷移工具 hermes claw migrate:從 OpenClaw 匯入設定、記憶、技能、API 金鑰,含 --dry-run
技能格式 相容 agentskills.io 開放標準 自有格式

其中兩項跟我的處境直接相關:

執行後端。 它租算力,我用自己的機器。這是不同的取捨——我選自有硬體是為了資料不離開我控制的範圍,代價是沒有「閒置時不花錢」這個選項。反過來說,serverless 後端的資料會經過第三方。兩邊都不是免費的。

訂閱聚合。 我整個艦隊設計的出發點是「只負擔得起一個訂閱」,所以我用工程手段解:免費層輪替、本機模型、額度排程。Nous Portal 用商業手段解同一個問題——自己當聚合層,一份訂閱涵蓋五種服務。

工程手段的成本是複雜度,商業手段的成本是綁定。對我來說前者可行是因為我本來就要寫這套系統;對一般使用者來說後者顯然更合理。

遷移工具 值得單獨提一句,因為 claude 也有 import。兩家都把「從對手那邊搬過來」做成一級功能——使用者已經累積的東西才是真正的鎖定,所以降低搬家成本是產品策略。我這邊沒有這個概念,技能格式又是自有的,兩件事加起來是孤島風險。

對照完之後,我得修正自己的說法

在查 hermes 之前,我的盤點是「四家都沒有、而我有五樣」。加進 hermes 之後,其中兩樣站不住:

原本宣稱領先 加上 hermes 之後
技能萃取/召回 ❌ 這是 hermes 的主打功能,而且相容開放標準
生活記憶 ❌ hermes 有記憶策展、跨 session 搜尋、使用者建模
跨機 mesh ⚠️ 部分成立——它用七種執行後端解同一個問題,取捨不同
驅動別家 AI CLI 並包進治理層 ✅ 成立
反幻覺掃描器 ✅ 成立

從五樣變成兩樣半。

這個修正對我有用,因為它把「我以為的差異化」和「真的差異化」分開了。剩下那兩樣有個共通點:它們不是功能,是紀律。 一個是「讓別家 AI 在我的治理下工作」,一個是「當 AI 宣稱做了事而其實沒做時,能自動抓到」。


三、我的位置:領先兩樣半,落後九樣

五家都沒有、而我真正還握著的(修正後)

為什麼別人沒有
驅動別家 AI CLI 並包進治理層 codex 不驅動 claude,claude 不驅動 codex;hermes 驅動的是模型不是別家 agent。而我在它們外面加了一層硬煞車、簽章飛行記錄器和手機核准
反幻覺掃描器 五家我都沒看到
跨機 mesh(半樣) hermes 用七種執行後端解同一個問題,路徑不同:它租算力,我用自己的機器

其餘兩項(技能萃取、生活記憶)在 hermes 面前不成立,已從清單移除。

這兩樣半有個共通點:它們不是功能,是紀律。 一個是「讓別家 AI 在我的治理下工作」,一個是「當 AI 說謊時抓到它」。功能可以被抄,紀律不行——因為抄的人得先相信那件事重要。

下面把這兩項講細,因為它們是重構時唯一不能弄丟的。

反幻覺掃描器是一個確定性的正規表示式掃描器,專門抓一種回覆:訊息裡宣稱完成了有副作用的動作(「已建立檔案於⋯」「✅ 完成」),但那個回合的工具呼叫集合是空的。抓到就發出一個 ConsistencyWarning 事件。

它不用另一個模型去判斷,所以不會有第二層幻覺。它只比對一件事實:你說你做了,那你呼叫過工具嗎?

寫這篇的過程裡,我在五個不同的地方撞到同一個形狀的問題——綠燈,但什麼都沒做。這個掃描器就是那個問題在模型層的版本。五家商業 agent 我都沒看到誰有。

行程沙盒則是 shell 工具的每一次 spawn 都跑在作業系統層級的限制裡:Linux 用 Landlock、macOS 用 Seatbelt,預設拒絕。codex 走同一條路線,這是業界正解——差別在它把 sandbox 做成使用者可以直接呼叫的子命令,而我的埋在設定裡(見後面的「只差介面」)。

這是重構時的保護清單,不是行銷。意思是「別弄丟」。

清單從五樣縮到兩樣半這件事本身也是收穫——沒對照過就以為自己領先,是最便宜也最危險的自我感覺良好。

四家都有、而我缺的(dev trio + claude)

缺口 現況 性質
session 生命週期 只有即時檢視,無 resume / fork / archive 能力
結構化輸出 --json,無 stream-json、無 schema 能力
外掛系統 能力
attach 到執行中的 server serve,無 attach 能力
MCP 管理命令 client 已經接上了——agent 迴圈、工具註冊表、HTTP daemon 三處都在用,設定走設定檔的 mcp_servers 區塊;缺的只是 add/list/remove 子命令 只差介面
沙盒可見入口 Landlock / Seatbelt 已經在跑,但沒有子命令也沒有旗標 只差介面
每次呼叫指定降級模型 失效鏈已經有,缺一個旗標 只差旗標
預算上限 成本追蹤已經有,缺一個介面 只差介面
從別家匯入設定 能力

九項裡有四項是「做好了但沒露出來」。


四、所以要完善它,得先分清三件事

盤點完之後,「把 CLI 做好」這句話拆成三個很不一樣的動作。混在一起做,就會像我這幾天一樣連續踩坑。

檢查:讀碼,先確認哪一套是活的

最花時間、也最值錢的一步。三個實例:

① 兩層供應商,而路線圖描述的是死的那層。 樹裡有兩份供應商實作:一份 4,268 行、一份 8,727 行。agent 只從前者 import 了兩個提示詞格式的 helper,completion 完全走後者。而我的路線圖上寫的進度,講的是前面那份。

② 斷路器接在死線上。 一個 369 行、有完整測試、有可控時鐘的斷路器,在整棵樹裡唯一的非測試呼叫者,是那份不會被執行的鷹架

③ 而它的測試是綠的。 這是最難發現的一種。那五條測試寫得很好,檔頭寫著「end-to-end」,但它們的驅動函式是測試檔裡自己重寫的一套——自己呼叫斷路器的 allow()on_failure()。正式程式碼從來不呼叫。

它測的是一份「應該長這樣」的規格,而那份規格在正式程式碼裡不存在。
它是綠的、寫得很好——但它什麼都沒守住。

空測試至少數字是 0。這種有五條、全過、覆蓋面看起來完整。

同一個形狀,五個實例

把這幾天撞到的攤開來,會發現它們是同一種東西的不同穿著:

# 現場 為什麼是綠的 空轉多久
1 安裝腳本的乾跑保護 測試導出的環境變數用舊品牌前綴,腳本讀的是新的——兩邊名字對不上,保護從未生效 自改名起數週,而且它在 CI 裡跑
2 平台回歸測試的一條斷言 找一個舊檔名,而程式早就改產新檔名——斷言恆真 同上
3 每日回顧 資料格式漂移,讀取端拿不到任何事件;它照樣產出一份空的總結 12 天沒人發現
4 自測框架的 --feature 篩選 打錯 feature 名稱時回「0 通過, 0 失敗, 0 跳過」而退出碼是 0 未知
5 失效切換的五條測試 測的是測試檔自己重寫的一套驅動函式 未知

第 4 個最諷刺:那是負責抓前三個的工具,自己也會空轉。(這個已經修掉了:對不上的名稱現在退出碼 2,並列出所有合法名稱。)

而第 3 個最痛。它不是測試,是正在服務我的功能。一個號稱「越用越懂你」的系統,餵給它的資料斷了 12 天,而它每天都準時交出一份根據空集合寫成的回顧。

這五個湊在一起講的是同一句話:

「它在跑」和「它在做事」是兩件事,而多數系統只量得到前者。

測試:建立可以重跑的基準線,而且要先證明它會紅

檢查完要把結論釘成數字,否則下次改動沒有對照。

我寫了一條走真實路徑的測試:綁一個會計數的臨時埠,接受連線、計數、立刻掛斷;再放一個會回工具呼叫的第二家,好讓 agent 真的進入多回合。

死掉的供應商在一次任務裡被接觸 75 次,耗時 75 秒

MAX_RETRIES 只有 3。放大來源不是重試次數,是回合——供應商鏈每個回合都從頭重走一次,已知死掉的那家每回合都要再付一次完整的重試預算。

這條測試帶著兩個防護:

  • 防空綠:先斷言「接觸次數 > 0」。如果請求根本沒送出去,上限斷言會自動成立而證明不了任何事。第一版就差點掉進去——單一供應商時只有 3 次,綠得毫無意義。
  • 明寫解除條件:暫時標成 ignore,理由字串寫著「解除 ignore 就是這項工作的完成定義;不准用放寬上限的方式讓它過」。

修復:依序,每一步先紅後綠

修的順序不是照重要性,是照依賴:

第一波 · 先接活的那套
斷路器接進真實請求路徑,讓上面那條 75 次的紅測試變綠。順帶把「什麼算失敗」定義清楚——連線被拒和逾時該計入開路;400 / 422 是我自己的請求寫壞了,不該算在對方頭上;401 / 403 是這把金鑰的永久問題,直接換下一家但不必判它不健康。這個分類寫錯,斷路器會把健康的供應商踢出輪替。

第二波 · 把已有的能力露出來
那四項「只差介面」的:MCP 管理子命令、沙盒的可見入口、降級模型旗標、預算上限。成本最低、見效最快,而且做完就等於承認一件事——功能寫好了但沒人找得到,跟沒寫差不多。

第三波 · 補真正缺的
session 生命週期、結構化輸出、外掛系統。這幾項是真的要從零寫。

第四波 · 消除雙實作
最大的工程,但前三波做完會自然浮現該從哪裡下手。而且順序不能反——在還沒確認哪一套是活的之前就開始合併,只會把兩份債攪在一起。

一個我自己剛犯的錯,剛好示範這三步為什麼要分開

寫那條紅測試的時候,我沒先做完「檢查」就跳去「修復」——直接把斷路器接進我讀碼時認定的那條迴圈。

結果紅燈完全沒變。加了輸出一看:我接的那兩個地方,一行都沒被執行過。測試走的是另一條。

而且更糟:全套測試從 3 個既有失敗變成 4 個。多的那個看起來完全無關,但想得通——我把斷路器做成行程共用的,於是前一個測試打開的路,擋住了後面不相關的測試。 全域狀態在測試之間洩漏,在正式環境同樣會讓不相關的任務互相影響。

整段接線只好撤回。兩個教訓:不要憑讀碼猜哪條路是活的(今天第二次犯),以及斷路器要綁在執行個體上,不能是行程全域

這就是為什麼「檢查」不能省。省下來的時間,會在「修復」那一步用三倍還回去。


五、主論點

一天之內,同一個形狀出現了四次:

一套 另一套 哪套活著
4,268 行的供應商鷹架 8,727 行的供應商層 後者
10,630 行的終端機介面 11,195 行的 HTTP daemon + 網頁前端 兩套都活,各自維護
173 個桌面 IPC 命令 34 個 HTTP 端點 兩套都活,但網頁端只拿得到後者
34 個舊品牌的命令名 新的執行檔名 衛星專案那側全是舊的

而斷路器是極端案例:唯一的使用者是死的那套——其中一套不但不會被執行,還把另一套需要的零件綁在自己身上。

我原本以為對照商業級 CLI 會得到一張「他們有而我沒有」的功能清單。實際得到的不是這個。

他們的共通點與其說是功能比較多,不如說是每個能力只有一個實作,而且都露在介面上。

所以重構的第一原則不是「補功能」:

每個能力只留一個實作,證明它是活的那個,然後把它露出來。

今日頭條數字

執行期 8,727 行的供應商層旁邊,躺著 4,268 行不會被執行的同名鷹架;369 行的斷路器只接在後者身上;而一個死掉的供應商,在一次任務裡被重試了 75 次。

小結與明日預告

今天做的事是盤點,不是開發。但盤點本身有個副產品:我原本相信的進度,有一部分是假的——不是有人造假,是文件描述的那套程式碼不在執行路徑上,而測試又剛好測了同一套。

明天處理一個我拖很久的問題:我之後主要會用的是 app 版,各平台的 app,那這一整套 CLI 該怎麼辦?61 個 subcommand、兩萬行的入口檔,要怎麼「併進」app?

我最後的答案是不要併——而理由跟一個發現有關:我以為要大工程才能做到的事,翻程式碼才發現已經做好八成了。


上一篇
Day1 一顆 binary、五種身分:先看懂 spectyn CLI 的全貌
下一篇
Day03 CLI 要怎麼併進 app?我的答案是不要併
系列文
從單一agent 到多agent 集群的開發流水帳以及應用18
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言